Skip to content

Give masks a plane identity (Z/T); refuse 2D masks of multi-plane images - #43

Merged
guijacquemet merged 4 commits into
mainfrom
claude/mask-plane-identity
Oct 30, 2026
Merged

guijacquemet merged 4 commits into
mainfrom
claude/mask-plane-identity

Conversation

@guijacquemet

Copy link
Copy Markdown
Contributor

Summary

Fixes #36.

  • masks.py
    • find_masks reads each mask's axes from its own metadata: the tifffile series axes, or the OME-NGFF axes of a label image. New field MaskFile.axes; MaskFile.planes gives {"Z": n, "T": n}.
    • After length-1 axes are removed, a mask may have Y, X and at most one Z and one T. Any other or unnamed axis is still mask_not_2d, with a new message and action. This includes a plain stack that tifffile calls Q/I, which could be either depth or time.
    • New header error mask_planes_differ: the masks of one image do not have the same planes.
    • New mask_plane_issues(inventory, images, file_mappings) (exported): error mask_planes_mismatch when a mask's Z/T lengths are not its image's under the confirmed axis roles. So a 2D mask is refused for a Z-stack or a time-lapse.
    • read_mask(..., z=, t=) reads one plane. It requires z/t for a mask that has those axes and refuses an out-of-range plane. When planes are stored one per page it reads only that page; a volume stored in one page is read whole and the plane taken from it. OME-Zarr label images are sliced.
    • to_manifest() adds axes and shape to each mask entry.
  • run.py: run_experiment adds mask_plane_issues after the axis review, so mask_planes_mismatch blocks the run.
  • mask_scan.py: scan_masks scans a mask with Z/T one plane at a time, each plane counted like an image of its own. Objects are never matched across planes. MaskTypeSummary.images still counts images; objects counts a label once per plane it appears in.
  • Docs:
    • contract §8, §9 (masks.files[].axes, shape), §15 (new Planes row) and §15.1;
    • user guide (mask layout and the three codes);
    • plan Q32 follow-up note;
    • AGENTS.md decisions log.

Decisions / notes for review

  • Owner decision not taken: whether a 2D mask may stand for a projection of a Z-stack or time-lapse. I did not guess: it is refused (mask_planes_mismatch). Allowing it later would need an explicit, recorded confirmation.
  • Unnamed mask axes are refused, not inferred from the image. A TYX mask saved without axis metadata (QYX) is mask_not_2d. Letting the user confirm a mask's axes, as for the images, could be a later feature.
  • Manifest: I kept manifest version 1 and added the optional keys axes and shape, following this repo's existing pattern. Older manifests only ever held 2D masks. But a consumer that ignores axes and opens a TYX mask from a new manifest would get a 3D array. Contract §9 now says consumers must read axes and never apply a 2D mask to several planes. If you prefer a version bump here, note that the reader accepts only version "1": a bump would make every existing manifest unreadable and force a full rebuild.
  • check and the masks command have no confirmed roles, so they cannot compare masks with images. Only run reports mask_planes_mismatch. mask_planes_differ is found from the headers everywhere.
  • Contract §15's old line "Confirmed by the owner: masks are 2D … (3D and time-lapse masks are a later follow-up)" is now that follow-up. The issue asks for it.
  • This touches the same functions as Refuse negative mask labels (background was counted as an object) #40 (negative labels) and Report a malformed OME-Zarr labels group as unreadable_mask #41 (labels group). Whichever merges second needs a small rebase in masks.py / mask_scan.py.

Tests

New tests:

  • tests/test_masks.py:
    • test_a_2d_mask_of_a_time_lapse_or_z_stack_is_refused (main regression test)
    • test_a_mask_with_the_images_time_points_is_accepted_and_recorded (no pixel reads)
    • test_the_run_records_each_masks_planes
    • test_a_mask_with_other_planes_than_its_image_is_refused
    • test_a_mask_whose_extra_axis_is_not_named_is_refused
    • test_read_mask_reads_one_plane_and_never_guesses_which
    • test_read_mask_of_a_volume_stored_in_one_page
    • test_label_images_with_time_points (OME-Zarr, tyx)
  • tests/test_mask_scan.py::test_masks_with_time_points_are_scanned_plane_by_plane: a TYX scan gives the same evidence as three single-plane images.
  • test_the_run_records_the_masks_in_the_manifest now expects axes/shape.

Results:

  • On main, these 10 tests fail. The main regression test fails because the run with a 2D mask of a 3-frame time-lapse is written.
  • Full suite (Python 3.13, zarr 3.4.2, notebook extra installed): 1060 passed, 1 skipped. The skip is test_discovery.py:472: running as root ignores file modes.
  • ruff check . and ruff format --check . are clean.
  • Not run: zarr 2.18 / Python 3.11, Windows, macOS.

Generated by Claude Code

A mask was accepted when it was 2D with the image's Y and X sizes, with no
plane identity: one 2D mask paired with a time-lapse or Z-stack would be
applied to every plane, guessing object identity across T and Z, while
per-plane masks (TYX/ZYX) were refused.

- find_masks reads each mask's axes from its own metadata. Besides Y and X a
  mask may have Z and/or T; any other or unnamed axis (a plain stack that
  could be depth or time) stays mask_not_2d, never guessed. The masks of one
  image must share their planes (mask_planes_differ).
- mask_plane_issues compares each mask's Z/T lengths with its image's under
  the confirmed axis roles; run_experiment stops on mask_planes_mismatch, so
  a 2D mask is refused for a Z-stack or time-lapse.
- MaskFile.axes/planes; the manifest's mask entries gain axes and shape
  (optional keys, manifest version 1).
- read_mask(z=, t=) reads one plane (one TIFF page when stored per page) and
  requires z/t for a mask with those axes; scan_masks scans plane by plane.
- Contract §8/§9/§15, user guide, plan Q32 and AGENTS.md updated.

Fixes #36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Scma6W3LC11s3qjXHZuGDm
Review of #43 (issue #36): the README's masks bullet now says a mask is 2D or
has Z/T planes named in its own metadata, one per image plane; a 2D mask of a
Z-stack or time-lapse is refused (mask_planes_mismatch), projections included;
and the manifest records each mask's axes and shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbytvBRB9CmKQ5AAkhEFcv
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbytvBRB9CmKQ5AAkhEFcv

Copy link
Copy Markdown
Contributor Author

Review follow-up, pushed as 642eebc and 033ac4a (docs only):


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Merged main (#42) in: contract §8 error/warning rows combined (#43's mask plane errors plus #42's channel_names_differ as an error, and the channel-name warning removed); 1072 passed, 1 skipped (test_discovery.py:472, root), ruff clean, CI 7/7 green on 0ee3a78. Merging.


Generated by Claude Code

@guijacquemet
guijacquemet merged commit c6815c2 into main Oct 30, 2026
7 checks passed
@guijacquemet
guijacquemet deleted the claude/mask-plane-identity branch November 1, 2026 18:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Masks have no plane identity (T/Z)

2 participants